Skip to content
created by Aha00aAha00a at 2026-05-24
last modified by Aha00aAha00a at 2026-10-12
revision: 18

Dev Page

1. Tables

1.1. Page

Page 테이블은 페이지 revision 원본을 저장한다.

  • 키: (site, name, revision)
  • 본문: content
  • revision 메타: dateTime, user, remoteAddress, comment, isMinorEdit, viaApi, userApiKey(evolution 65·67, Dev Api)
  • permRead는 더 이상 사용하지 않으며 evolution 51에서 제거했다.

최신 본문, history, diff, blame, include, search처럼 revision 원본이 필요한 경로는 Page를 직접 읽는다.

1.2. PageMeta

PageMeta 테이블은 페이지별 최신 상태 캐시다.

  • 키: (site, name)
  • revision: 최신 revision
  • dateInserted: row 생성 시각
  • dateUpdated: 계산 완료 시각
  • image: 대표 이미지
  • description: 본문에서 뽑은 요약(evolution 66). redirect 페이지는 NULL
  • size: 최신 revision의 content length

PageMeta.permRead도 evolution 51에서 제거했다. 읽기 권한 판정은 Permission 기반으로 처리한다.

2. Calculation

페이지 계산 시 PageLogic.calculate가 최신 Page를 읽고 PageMeta.upsert를 수행한다.

링크(CalculatedLink)는 다른 페이지로 가는 것만 기록한다. 앵커([#x])와 질의([?x])는 그 페이지 자신을 가리키므로 뺀다(InterpreterWiki.extractLinkMarkup). 질의는 2026-10-03 까지 기록돼서, ?q=10 이라는 페이지로 가는 링크가 생겼고 인접 페이지 그래프가 그 링크 목록을 /api/links 에 물어 404 를 받았다. #!WikiSyntaxPreview 블록은 미리보기 쪽이 그리는 링크만 센다 — 뒤에 이름을 적은 인터프리터로 읽는다는 뜻이고, #!WikiSyntaxPreview Vim javascript 처럼 코드를 보여 주는 블록에는 링크가 없다. 같은 날까지는 언제나 위키 문법으로 읽어서, InterpreterVim 의 JavaScript 예시가 링크 하나를 만들고 있었다.

#! 줄이 없는 [[[ ]]] 블록은 텍스트로 그려지므로 링크도 없다. 블록을 읽는 쪽 — 그리기, 감싸는 div 의 class, 링크, schema.org — 은 모두 ExtractConvertInjectInterpreter.withInterpreter 를 거쳐 같은 것으로 읽는다. 2026-10-03 까지는 그리기만 텍스트로 읽고 링크 계산은 위키 문법으로 읽어서, 사용법 줄의 [options...] 나 정규식의 [a-z0-9] 가 그 이름의 페이지로 가는 링크가 됐다(aha00a.com 에서 41페이지, 링크 248개). 인접 페이지 그래프는 그 이름들을 /api/links 에 물었고, 감싸는 div 의 class 는 Wiki 였다.

계산은 저장할 때, PageMeta 가 없는 페이지를 스케줄러가 찾았을 때, 그리고 페이지 보기 열 번에 한 번 돈다(Wiki.scala). 그래서 읽는 요청이 늘 가까이 있다. 보기에서 온 계산은 2026-10-12 부터 그 페이지가 최신 리비전으로 하루 안에 계산돼 있으면 건너뛴다(PageLogic.isCalculatedRecently). 계산이 만드는 것 대부분 — 설명, 대표 이미지, 단어 빈도, 링크, schema.org — 은 그 페이지 자신의 글에서 나오고, 글은 편집으로만 바뀌며 편집은 저마다 계산을 부른다. 보기로만 따라오는 것은 유사도다: 유사도 행은 계산한 페이지 쪽에서만 쓰므로, 다른 페이지의 편집이 이 페이지의 비슷한 페이지 목록에 닿는 것은 이 페이지가 다시 계산될 때다. 그래서 그 목록은 이제 최대 하루 늦을 수 있고, 계산 방식을 바꾼 배포가 편집 없는 페이지에 닿는 것도 그 페이지가 보인 뒤 하루 안이다. 하루로 고른 이유와 잰 값은 PageLogic.ViewRecalculationInterval 의 주석. 단어 빈도(CalculatedTermFrequency.replace)와 유사도(CalculatedCosineSimilarity.recalc)는 지우고 다시 넣는 일이라 각각 한 트랜잭션으로 묶는다. 2026-10-02 까지는 따로 커밋해서, 그 사이에 MacroSimilarPages 를 그린 독자는 공통 단어나 유사 페이지가 일부 빠진 목록을 봤다 — 배포 대조에서 같은 코드의 두 인스턴스가 다른 단어 목록을 낸 것이 그것이었다. 두 인스턴스가 겹치는 행을 동시에 다시 계산하면 교착(deadlock)이 날 수 있다 — 서로 비슷한 두 페이지는 같은 유사도 행을 쓴다. 그래서 두 곳 다 LocalTransaction.retryingDeadlock 으로, 교착의 희생자가 되면 세 번까지 다시 돈다. 파생 데이터라 두 번 돌아도 결과가 같다.

처음에는 재시도 없이 «그 계산이 버려질 뿐이고 다음 계산이 다시 쓴다» 고 적었는데 틀렸다. 트랜잭션을 넣기 전 53일치 로그에 교착은 0건이었고, 넣은 지 한 시간 안에 한 건이 났다(2026-10-03 05:35, 배포 대조가 모든 페이지를 여는 동안). 예외는 유사도에서 났지만 계산이 거기서 멈추므로 그 뒤의 링크·schema 계산까지 같이 빠졌고, 액터의 ERROR 로 남았다.

2026-10-03 배포에서 확인했다. 한 대만 새 코드였을 때는 옛 코드 쪽이 Dev Database 를 다시 계산하는 3초 동안 새 쪽이 빈 단어 목록을 그렸다. 두 대를 다 올린 뒤에는 그 페이지를 150번 그리는 동안 재계산이 열여섯 번 넘게 돌았는데, 빈 목록은 한 번도 없었다. 그때는 교착 로그도 없었지만, 50분 뒤 다음 배포의 대조 중에 위의 한 건이 났다.

대표 이미지는 다음 순서로 선택한다.

  • #!Schema의 image 또는 logo
  • 업로드된 Attachment
  • [[Image(...)]]
  • [[Attachment(...)]]

3·4 의 매크로는 페이지가 실제로 실행하는 것만 센다 — 코드 표기(백틱) 안의 것, 그리고 렌더러가 본문을 손대지 않는 블록 안의 것은 건너뛴다. 본문을 그대로 보여 주는 #!Vim·#!Text·#!WikiSyntaxPreview 와, 원문을 그대로 넘기는 #!Kanban·#!Gantt·#!Mermaid·#!Math·#!Html 이다(PageLogic.markupOnly, 렌더러가 백틱을 그대로 두는 블록과 같은 목록). 칸반 카드에 올린 첨부는 2 의 업로드된 Attachment 로 잡힌다. 2026-10-03 까지는 원문 전체에서 찾아서, 위 목록을 백틱으로 적은 이 페이지의 이미지가 ... 이었고 이 페이지를 보여 주는 그래프마다 /... 을 요청해 404 를 받았다. InterpreterKanban 은 예시 카드의 [[Attachment(path/to/file.png)]] 가 이미지가 되어 역시 404 였다.

이미지가 없으면 NULL로 저장한다. API(pagePreview, links)는 빈 문자열을 돌려주고, 화면 쪽 스크립트가 attachmentFallback.svg 를 대신 쓴다. og:image·twitter:image 는 대표 이미지와 무관하게 언제나 attachmentFallback.svg 다(_baseSkeleton.scala.html) — PageMeta.image 는 뷰로 넘어가지 않아, 대표 이미지가 있어도 소셜 미리보기에는 쓰이지 않는다.

3. Read Paths

페이지 이름 목록과 최신 페이지 목록/size는 PageMeta 기반 캐시를 사용한다.

  • AhaWikiCache.PageMeta.SeqPageName
  • AhaWikiCache.PageMeta.SeqPageLatestSummary
  • ContextSite.setPageName
  • ContextSite.seqPageByPermission
  • 재계산 대상 선택(adminSiteCalculate). Admin Page 목록은 캐시 없이 PageMeta 테이블을 페이징해 직접 읽는다

페이지 생성, 수정, 삭제, rename 시 관련 PageMeta row 를 갱신하거나 지우고 SeqPageLatestSummary 를 무효화한다. SeqPageName 은 그때 무효화하지 않는다 — 관리자의 캐시 삭제(invalidateSiteCaches)가 아니면 TTL(개발 1분, 운영 5분)로 만료된다.

이 둘은 Redis 에 있지만 읽을 때마다 받아 해독하지는 않는다 — 버전 키로 해독한 값을 다시 쓴다. Dev Cache 의 «해독한 값을 프로세스에 둔다».

3.1. 본문을 많이 읽는 두 경로 — blame 과 검색

둘 다 익명이 열 수 있고 결과를 캐시하지 않아서, 한 번에 읽는 양에 상한을 두었다(2026-10-07). 그 전에는 둘이 JVM 의 순간 메모리를 가장 크게 밀어 올릴 수 있는 곳이었다 — 운영 저장소의 server/README.md 에 위키 JVM 의 anon 메모리가 요청량이 평소인데도 1분에 401MB 오른 기록이 있다.

  • blame 은 Page.selectHistoryStream 으로 리비전을 오래된 순서로 접는다. 이름과 달리 예전에는 한 쿼리로 모든 리비전의 본문을 한꺼번에 읽었고, 드라이버가 결과셋을 통째로 버퍼에 담으므로 긴 역사를 가진 페이지에서는 모든 리비전 본문이 바이트와 문자열로 두 번 메모리에 올랐다. 지금은 기본 키 범위로 20개씩(HistoryChunkSize) 읽는다. 경계에서 리비전이 빠지거나 두 번 읽히지 않는지는 PageHistoryStreamSpec 이 본다.
  • 검색 /search 는 Page.pageSearch 가 SearchResult.MaxPages(100) 페이지까지만 읽는다 — 이름이 검색어와 똑같은 페이지가 먼저, 나머지는 최근에 바뀐 순서다. 한 개 더 읽어 넘쳤는지 알고, 넘쳤으면 화면이 그렇다고 말한다. 읽기 권한은 그 뒤에 거르므로, 읽을 수 있는 페이지가 더 있어도 100개보다 적게 보일 수 있다. 한 페이지에서 보여 주는 일치 줄은 SearchResult.MaxHitLinesPerPage(20)까지이고 나머지는 개수만 적는다. 정규식은 페이지마다 한 번 컴파일한다 — 예전에는 줄마다 컴파일했다.
    • 바꾸기 전 실측(2026-10-07, 가장 큰 사이트, 루프백): /search?q=a 가 HTML 14.6MB 를 1.6초에 냈다.
    • robots.txt 가 /search 를 막는다. 검색 결과는 이미 X-Robots-Tag: noindex 였으니 크롤러가 긁어 갈 이유가 없었다.
    • IpRateLimiter 가 /search 를 페이지 조회로 세게 하는 것은 하지 않았다. 검색 화면은 /w/ 화면보다 곁들이는 요청이 적어서, 사람이 몇 번 이어 검색하는 것만으로 «페이지만 긁기» 에 걸릴 수 있다. 상한이 비용을 묶으므로 그 위험을 질 이유가 없다.

4. 없는 페이지 화면

리비전이 없는 이름을 열면 Wiki.view 가 renderNotFoundPage 로 간다. DefaultPageLogic.getOption(name) 이 날짜·schema: 처럼 내용을 지어내는 이름이면 그 템플릿을, 아니면 WikiSnippet.notFound(name) 을 렌더한다.

이 이름은 URL 이 담고 있던 무엇이든이다. WikiSnippet.notFound 는 그 이름으로 위키 마크업(제목과 검색 링크)을 짓기 때문에, 이름을 글자 그대로로 다룬다 — asLiteralText 가 [ 를 ( 로 바꿔서 링크·매크로([[Html(...)]])·블록([[[#!Html ...]]])이 만들어지지 않게 한다. 제목의 < 는 렌더러가 이스케이프하고, 제목에서 나오는 anchor 는 id·class 속성에 속성값으로 이스케이프해 들어간다(InterpreterWiki). 2026-09-15 전에는 이름이 마크업으로 렌더돼서, [[Html(<b>x</b>)]] 나 </script><b>x</b> 를 URL 에 넣으면 없는 페이지 화면에서 살아 있는 HTML 이 됐다 — 링크 하나로 아무나 터뜨리는 반사형 XSS 였다.

5. 옛 리비전 화면

히스토리에서 번호를 누르면 /w/{페이지}?revision=N 으로 간다. Wiki.view 가 그 리비전을 읽어(Page.select) 본문을 그리고, 뷰에는 그 리비전을 wikiShown 으로, 최신을 wikiLast 로 넘긴다. 둘의 번호가 다르면 옛 리비전이다.

옛 리비전이면 뷰(Wiki/view.scala.html)가 세 가지를 다르게 한다.

  • 본문 위에 안내 상자(oldRevisionNotice)를 둔다 — 몇 번째 리비전인지, 언제 누가 저장했는지, 최신은 몇 번인지. 링크는 최신 보기와, 그 리비전에서 최신까지의 diff 다.
  • 쓰기 권한이 있어도 data-wiki-writable="false" 로 그린다. Kanban 보드를 읽기 전용으로 만드는 값이 이것이다(InterpreterKanban Read Only).
  • 페이지의 WebSocket 을 열지 않는다(Dev WebSocket).

오른쪽 위 revisionInfo(created by · last modified by · revision)는 그대로 최신을 말한다. 페이지에 대한 사실이라 틀린 말은 아니고, 지금 보이는 것이 무엇인지는 안내 상자가 말한다. 문단마다 붙는 편집 링크도 그대로 그 리비전을 가리킨다 — 열어서 저장하면 최신과 번호가 달라 409 로 거절된다(Dev Editor 부분 편집 링크).

검사는 WikiOldRevisionViewSpec 이다. 옛 리비전과 최신을 각각 그려서 안내 상자·읽기 전용·소켓을 확인한다.

5.1. 왜

2026-09-26 전에는 본문만 옛 리비전이고 둘레는 전부 최신을 말했다. revisionInfo 는 최신의 수정자·날짜·번호를 보였고, 옛 리비전이라는 표시는 어디에도 없었다. 그래서 «히스토리에서 번호를 눌러도 현재 버전으로 돌아간다» 는 보고가 왔다 — 차이가 작은 리비전이면 정말로 구별할 수 없다.

Kanban 페이지는 실제로 최신으로 돌아갔다. 보드는 page.updated 를 받으면 최신 원문을 읽어 다시 그리는데(Dev Kanban Realtime), 옛 리비전 화면도 소켓을 열고 있었으므로 누가 그 페이지를 저장하는 순간 보드가 최신 보드로 바뀌었다. ToDo 의 r80 화면에서 이 이벤트를 흉내 내자 카드 셋이던 보드가 최신의 둘로 바뀌었다(2026-09-26). 2026-09-25 부터는 API 저장(문서 sync)도 page.updated 를 내므로 흔한 일이 됐다. 새로고침 토스트도 떴는데, 누르면 같은 옛 리비전을 다시 불러올 뿐이었다.

읽기 전용도 같은 까닭이다. 옛 리비전으로 그린 보드에서 카드를 옮기면, 첫 저장이 최신 원문을 읽어 그 변경을 최신 보드 위에 얹어 저장하고(rebaseKanbanColumns) 화면은 최신 보드가 된다. 옛 리비전을 고친 것이 아니라 최신을 고친 것이 되므로, 그 화면에서는 고치지 못하게 했다.

revisionInfo 의 번호는 보이는 리비전으로 바꾸지 않았다. AhaWiki.Kanban.js 가 그 링크의 글자(.revision a)를 «보드를 그린 리비전» 으로 읽어(getCurrentRevision) 저장과 따라잡기에 쓴다. 최신 화면에서는 그게 맞는 값이고, 옛 리비전 화면에서는 저장도 따라잡기도 하지 않으므로 읽히지 않는다.

6. 페이지명과 URL

페이지명을 URL 조각으로 바꾸는 것과 되돌리는 것은 logics.wikis.PageNameUrl 한 곳에 있습니다.

  • encode — /w/{encode(name)} 형태로 쓰는 경로 조각. UriUtil.encodeURIComponent 와 같아서 !'()~ 는 그대로 둡니다.
  • decode — 라우트로 들어온 이름을 되돌립니다. + 를 %2B 로 먼저 바꾸는데, 이걸 빼면 페이지명 안의 + 가 공백으로 읽혀 엉뚱한 페이지를 찾습니다.

전에는 decode 가 4곳, encode 가 8곳에 흩어져 있었고 encode 는 두 형태로 갈라져 있었습니다. 어떤 코드가 링크를 만드느냐에 따라 **같은 페이지의 URL 이 두 가지** 였습니다 — sitemap, 텔레그램 알림, 렌더링된 위키 링크가 서로 달랐습니다.

encodeURIComponent 쪽으로 맞춘 이유는 브라우저가 이미 그 함수를 쓰기 때문입니다. 서버가 만든 링크와 클라이언트가 만든 링크가 같아집니다. 두 형태 모두 같은 페이지로 해석되므로 겉모습 차이였지만, 정규 URL 이 두 가지인 상태는 그대로 둘 수 없습니다.

페이지명을 조각으로 쓰는 자리는 이제 모두 PageNameUrl.encode 를 부릅니다 — sitemap·랜덤·redirect(Home·Wiki), PageList, schema.org 페이지 URL(InterpreterSchema). UriUtil.encodeURIComponent 직접 호출은 페이지명이 아닌 것에만 남습니다 — InterpreterSchema 의 지도 검색어(Google·Naver·Kakao)가 그것입니다. 2026-09-15 전에는 페이지 화면의 <link rel="canonical"> 과 og:url 이 URLEncoder + %20 형태여서 () 를 %28%29 로 적었고, 렌더된 링크(() 그대로)와 정규 URL 이 또 갈라져 있었습니다.

브라우저 쪽도 같은 함수입니다 — 스크립트가 페이지명으로 URL 을 만들 때는 '/w/' + encodeURIComponent(name) 입니다. 인접 페이지 그래프의 /api/links 요청과 노드 클릭, 그래프 화면의 노드 클릭, 검색 자동완성의 이동(hotkey.scala.html)은 2026-10-03 까지 이름을 그대로 붙였습니다. 그래서 # 이나 ? 가 든 이름은 그 앞까지만 페이지명으로 읽혔습니다 — 2017-03-07 이 링크하는 ??? 는 그래프가 /api/links/ 에 물어 404 를 받았고, C# 이었다면 C 로 갔을 것입니다.

7. See Also

7.2. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • Same Wiki
    • 47.20% ToDo page(42:37), 같은(10:62), 페이지(20:31), wiki(19:30), 다시(9:38), dev(8:38), url(13:29), api(9:32), aha00a(1:40), 없는(7:31)
    • 46.73% Dev WebSocket page(42:68), wiki(19:29), updated(3:43), 페이지(20:20), kanban(7:25), 같은(10:14), revision(15:7), name(11:9), redis(1:19), 최신(14:5)
    • 37.33% Dev Cache page(42:21), cache(3:60), wiki(19:36), site(6:35), aha(3:36), 캐시(2:30), 페이지(20:8), seq(5:18), meta(12:9), api(9:12)
    • 36.85% Api api(9:84), page(42:27), 페이지(20:19), wiki(19:15), revision(15:19), key(1:32), name(11:20), 최신(14:7), via(1:20), aha(3:14)
    • 35.77% Dev Attachment page(42:25), attachment(6:42), url(13:25), 페이지(20:12), name(11:13), wiki(19:2), api(9:11), 같은(10:8), 그대로(9:6), site(6:9)
    • 35.62% Dev Database page(42:13), wiki(19:7), 같은(10:15), 페이지(20:4), 다시(9:9), 다른(3:15), api(9:8), date(3:14), revision(15:1), meta(12:4)
    • 33.40% Dev Editor page(42:13), wiki(19:14), schema(5:21), api(9:15), interpreter(9:15), 편집(3:20), aha(3:19), 페이지(20:1), edit(1:20), revision(15:4)
    • 31.77% MacroPageCount page(42:2), 페이지(20:2), wiki(19:1), 있는(3:1), 없이(2:1), macro(1:2), 관련(1:1), 권한이(1:1), 사용법(1:1)
    • 31.39% AccessControl page(42:34), permission(2:53), 페이지(20:23), wiki(19:22), site(6:31), read(3:31), admin(2:25), revision(15:8), url(13:9), 같은(10:10)
    • 31.09% Dev Telegram page(42:13), 페이지(20:9), wiki(19:10), url(13:15), name(11:10), revision(15:2), api(9:8), 링크(11:2), site(6:7), html(9:3)
    • 30.72% HotKey page(42:8), wiki(19:2), search(7:3), 검색(7:1), 페이지를(6:2), 페이지의(5:2), aha(3:3), 편집(3:3), blame(4:1), history(4:1)
    • 30.23% Dev Logs page(42:9), dev(8:17), 페이지(20:2), wiki(19:1), 같은(10:7), revision(15:1), 있다(5:11), 최신(14:1), meta(12:3), url(13:1)
  • Sister Wikis
    • 32.24% Aha00a:ProjectAhaWiki page(42:7), revision(15:12), name(11:16), wiki(19:3), aha00a(1:8), content(2:5), time(1:6), max(2:4), select(2:4), aha(3:2)

7.3. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+